Day 22:測試替身 Test Doubles

今日讀物:《Clean Craftsmanship 無暇的程式碼 軟體工匠篇》(Chapter 3 進階 TDD)、《Test-Driven Development 學習手冊》(迅速翻完,沒有實作)

測試替身 Test Doubles

Test Double

2000 年 Gerard Meszaros 的著作《xUnit Test Patterns: Refractoring Test Code》定義了非正式模擬物件 (Mock Object) 的五種類型,統稱這些物件為「測試替身 Test Doubles」:執行測試時,測試替身代替了另一個物件

  • Dummy 虛擬物件

  • Stub 虛擬常式或擬態物件

  • Spy 情蒐或間諜物件

  • Mock 模擬物件

  • Fake 假物件

                                              Test Double
                                                   |
                           +-----------------------+------------------------+
                           |                                                |
                        行為鏈:能力遞增                                  簡化真實實作
                           |                                                |
                只佔位 ──► Dummy                                             |  
			(不驗證,不被使用) 												|
                           |                                                |
              給假資料 ──► Stub												|
			(控制間接輸入,做 state verification)								|
                           |                                                |
              記錄互動 ──► Spy												|
			 (記錄間接輸出,事後 assert 行為)									|
                           |                                                |
    檢查互動是否符合預期 ──► Mock                                         Fake Object
			 (事先定義期望,行為驗證 behavior verification)              (有邏輯的簡化實作)

使用時機

Dummy 虛擬物件:只佔位置的參數

被測函式需要輸入物件但不需要對物件做任何邏輯處理的時候,Dummy 此時實作介面 Implement interface 但什麼都不做,以避免在測試程式中宣告一個複雜的物件,通常不會真的被 SUT (System Under Test) 使用到,就是在輸入參數的地方佔位;若要回傳通常會是 null 或 0,多數情況不會被讀取

例:登入驗證函式必須在接收使用者名稱和密碼輸入值後才啟動,Dummy 就可以直接設定成一組空的使用者名稱和密碼 Uncle Bob 個人不是很喜歡,因為會有路徑引用和依賴鏈問題

// auth-service.js
export const login = (username, password, logger) => {
  // logger 會被呼叫,但這個測試用不到
  if (!username || !password) {
    logger.error('missing credentials');
    return false;
  }
  return true;
};

// auth-service.test.js
import { describe, it, expect } from 'vitest';
import { login } from './auth-service.js';

describe('login', () => {
  it('returns false when username is empty', () => {
    const dummyLogger = {
      // 實作介面,但什麼都不做
      error: () => {},
    }; // Dummy

    const result = login('', 'password', dummyLogger);

    expect(result).toBe(false);
  });
});
  • dummyLogger 實作了需要的介面 (error),但測試完全不會對它做任何驗證,就是純佔位。

Stub 虛擬常式或擬態物件:提供測試專用值

Stub 是一種 Dummy,但可以回傳一些驅動被測函式繼續執行邏輯流程的值 test-specific value (或稱測試專用值),用 SUT 的狀態或回傳值驗證 回傳的是「預先寫死」的值,不太關心呼叫了幾次、順序如何

例:回傳 true/ false,確認登入驗證函式接收合法/非法使用者名稱和密碼輸入值後是否按照設計流程執行

// user-repo.js
export class UserRepository {
  findByUsername(username) {
    throw new Error('not implemented');
  }
}

// auth-service.js
export const createAuthService = (userRepository) => ({
  async login(username, password) {
    const user = await userRepository.findByUsername(username);
    if (!user) return false;
    return user.password === password;
  },
});

// auth-service.stub.test.js
import { describe, it, expect } from 'vitest';
import { createAuthService } from './auth-service.js';

describe('AuthService with Stub', () => {
  it('returns true when username and password match', async () => {
    const userRepoStub = {
      // 只回傳測試專用值
      async findByUsername(username) {
        if (username === 'alice') {
          return { username: 'alice', password: 'secret' };
        }
        return null;
      },
    }; // Stub

    const auth = createAuthService(userRepoStub);
    const result = await auth.login('alice', 'secret');

    expect(result).toBe(true);
  });
});
  • userRepoStub 不記錄誰呼叫它、也沒有期望被呼叫幾次,只單純「給假資料」,這就是 Stub。

Spy 情蒐或間諜物件:記錄互動,驗證在測試裡做

Spy 是一種 Stub,回傳測試專用值使被測函式通過期望路徑,同時會「紀錄被怎麼呼叫」(包含呼叫的歷史、呼叫時使用的參數),之後由測試來檢查紀錄與斷言

// email-service.js
export const notifyLogin = async (emailClient, user) => {
  await emailClient.send({
    to: user.email,
    subject: 'Login detected',
  });
};

// email-service.spy.test.js
import { describe, it, expect } from 'vitest';
import { notifyLogin } from './email-service.js';

describe('notifyLogin with Spy', () => {
  it('sends an email to user email', async () => {
    const calls = [];

    const emailClientSpy = {
      async send(message) {
        calls.push(message);
        // 也可以在這裡回傳測試專用值
        return { ok: true };
      },
    }; // Spy:同時是 Stub + 記錄呼叫

    const user = { email: 'user@example.com' };

    await notifyLogin(emailClientSpy, user);

    // 驗證「怎麼被呼叫」在測試裡寫
    expect(calls).toHaveLength(1);
    expect(calls[0]).toMatchObject({
      to: 'user@example.com',
      subject: 'Login detected',
    });
  });
});
  • emailClientSpy 自己不會「判斷測試成敗」,只是把資訊記下來,由測試 expect 來驗證,這就是 Spy 的典型用法。

  • 如果使用 Vitest 的 vi.fn() ,不對它做任何期望檢查,只讀 mock.calls 來 assert,其角色偏向 Spy;一旦搭配 toHaveBeenCalledWith 等 API,就是 Mock。

Mock 模擬物件:事先設定期望,由框架驗證

Mock 是一種 Spy,回傳測試專用值使被測函式通過期望路徑,同時會記錄被怎麽呼叫,根據事先設定的預期結果判斷測試成功或失敗,屬於行為驗證(behavior verification)

Spy 和 Mock 都用來驗證間接輸出,只是一個是「把記錄交給測試自行斷言」,一個是「把期望預先設定好,由框架檢查」。

Uncle Bob 個人不太喜歡,因為 Mock 讓 Spy 的行為和測試驗證整個流程緊密耦合,而不是他個人偏好的直接陳述驗證內容

// payment-service.js
export const chargeOrder = async (paymentGateway, order) => {
  const amount = order.items.reduce((sum, item) => sum + item.price, 0);
  return paymentGateway.charge(order.userId, amount);
};

// payment-service.mock.test.js
import { describe, it, expect, vi } from 'vitest';
import { chargeOrder } from './payment-service.js';

describe('chargeOrder with Mock', () => {
  it('charges total amount to user', async () => {
    const paymentGatewayMock = {
      charge: vi.fn().mockResolvedValue({ success: true }),
    }; // Mock function:有預期回傳、也會被驗證行為

    const order = {
      userId: 'u1',
      items: [
        { price: 10 },
        { price: 20 },
      ],
    };

    const result = await chargeOrder(paymentGatewayMock, order);

    expect(result).toEqual({ success: true });

    // 行為驗證:這裡才是「Mock 的重點」
    expect(paymentGatewayMock.charge).toHaveBeenCalledTimes(1);
    expect(paymentGatewayMock.charge).toHaveBeenCalledWith('u1', 30);
  });
});
  • 嚴格依照 Fowler 的語意,這種「先設定 mockResolvedValue,再 toHaveBeenCalledWith」的用法,就是典型 Mock:同時驗證狀態與互動

  • Vitest 的 mock function vi,fn() 可以同時扮演 Stub + Spy + Mock,差別在你只設定回傳值、讀 mock.calls 斷言,還是再搭配 toHaveBeenCalledWith 等行為驗證 API。

Fake 假物件:可運作但簡化的實作

Fake 不是 Dummy, Stub, Spy 或 Mock,它是一種模擬器 simulator,是「真正可工作的簡化實作」,重點在於資料結構和邏輯是存在的,只是比正式實作簡單/走捷徑,例如 in‑memory DB、in‑memory mail sender。

Uncle Bob 很少用 Fake,因為 Fake 會隨著系統複雜度提高、測試條件變多而變大變複雜

// real-user-repo.js
export class RealUserRepository {
  constructor(db) {
    this.db = db;
  }

  async findByUsername(username) {
    // 真實情況:打 DB 或 API
    return this.db.query('SELECT * FROM users WHERE username = ?', [username]);
  }
}

// in-memory-user-repo.fake.js
export class InMemoryUserRepository {
  // Fake:可運作、但只存在記憶體
  constructor(initialUsers = []) {
    this.users = new Map(initialUsers.map((u) => [u.username, u]));
  }

  async findByUsername(username) {
    return this.users.get(username) ?? null;
  }

  async save(user) {
    this.users.set(user.username, user);
  }
}

// 使用 Fake 的測試
import { describe, it, expect } from 'vitest';
import { createAuthService } from './auth-service.js';
import { InMemoryUserRepository } from './in-memory-user-repo.fake.js';

describe('AuthService with Fake repository', () => {
  it('logs in with a real-like repo in memory', async () => {
    const userRepoFake = new InMemoryUserRepository([
      { username: 'alice', password: 'secret' },
    ]); // Fake

    const auth = createAuthService(userRepoFake);
    const result = await auth.login('alice', 'secret');

    expect(result).toBe(true);
  });
});

  • InMemoryUserRepository 有「狀態」與「邏輯」,甚至有 save,已經相當接近真實實作,但因為沒有交易、沒有真正的 I/O,所以是典型 Fake。

風險

可能無法完全複製真實依賴關係,無意間掩蓋了真實情境不明顯的副作用 bug,或引入額外的、真實情境不存在的 bug

TDD 的不確定性原則

  • 確定性越高,測試就越沒有彈性

  • 測試彈性越高,確定性越低

測試的脆弱性

Spy 測試很脆弱,因為測試本身和實作行為高度耦合,演算法變更就會讓測試需要修正或重寫,Mock 亦同

這是 Uncle Bob 不喜歡模擬工具 mocking tool 的原因

Uncle Bob 個人喜歡彈性高一點,所以會選用值測試(配對輸入和輸出值)和屬性測試(使用一群輸入值確認條件不變)

也就是 Fowler 文章裡的「狀態驗證」

狀態驗證測試對實作細節相對不敏感;行為驗證測試表達力強但脆弱度高

這份取捨直接影響程式架構的設計過程,也就是解決問題的思考流程、實作使用者介面和商業邏輯的推導過程不同